延續前一篇「Effect 資源管理(一)」,本篇聚焦在 Scope 與相關的 API:Effect.addFinalizer、Effect.scoped、Effect.acquireRelease、Effect.acquireUseRelease。我們直接開始~
Scope 是什麼?把 Scope 想成一個「會在結束時自動幫你收尾」的容器。你可以在過程中不斷把清理動作丟進去。等到這個 Scope 結束,系統會自動照順序幫你執行。
import { Console, Effect, Exit, Scope } from "effect";
const demoManualScope = Effect.gen(function*() {
const scope = yield* Scope.make()
yield* Scope.addFinalizer(scope, Console.log("Finalizer 1"))
yield* Scope.addFinalizer(scope, Console.log("Finalizer 2"))
yield* Scope.close(scope, Exit.succeed("scope closed"))
})
Effect.runFork(demoManualScope)
程式碼運作如下:
Scope(資源生命週期容器)。Scope,系統在關閉時自動執行所有 finalizer。Scope.close(scope, Exit.succeed("scope closed")) 需要一個 Exit 告知關閉原因(成功或失敗)。Scope 是一個可不斷追加清理工作的容器,關閉時統一收尾。我個人實務上除非必須,不然盡量不直接用 Scope.make() 建立 Scope 來手動管理資源的生命週期。我更加 prefer 宣告式 (declarative) 寫法,你也是對吧?🙂
Effect.addFinalizer 與 Effect.scopedEffect.addFinalizer 用來註冊「結束時要做什麼」;Effect.scoped 則負責「這段範圍何時結束」。兩者合起來,就能把資源範疇界定好,讓收尾工作在結束時一次做完。
為什麼:例如串流處理任務(讀檔→解析→寫出),會在流程中不斷追加需要清理的「暫時資源」(暫存檔、開啟的 reader/writer、外部鎖)。這些清理動作的數量/順序會隨流程演進而變化。用 addFinalizer 將清理逐步掛上當前 Scope,最後由 Effect.scoped 統一在結束時執行,避免遺漏。
// 執行時註冊一個 finalizer
const withFinalizer = Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("Last!"))
yield* Console.log("First")
})
// 以 scoped 提供 Scope 包住的地方界定資源範疇
// ┌─── const scoped: <void, never, Scope>(effect: Effect.Effect<void, never, Scope>) => Effect.Effect<void, never, never>
// ▼
Effect.runFork(Effect.scoped(withFinalizer))
從 Effect.scoped 的型別我們可以知道,若未以 Effect.scoped(或手動建立/關閉 Scope)包住,直接執行需要 Scope 的 Effect 會因缺環境依賴而無法執行。這樣的設計很好的把資源範疇界定在 Effect.scoped 內,避免資源外洩。
我們也能把 Effect.scoped 放在內部而非運行邊界,將資源使用範圍限制在更小的範圍:
const fineGrained = Effect.gen(function*() {
// 在這裡就結束該 Scope
yield* Effect.scoped(Effect.addFinalizer(() => Console.log("Last!"))) // finalizer 註冊後,因 Scope 關閉而執行
yield* Console.log("First")
})
Effect.runFork(fineGrained)
Effect.acquireRelease很多資源都有「取得」與「釋放」兩個階段(像檔案 handle、資料庫連線)。Effect.acquireRelease 能把這對行為綁在一起,並交給外層的 Effect.scoped 決定何時收尾。
在同一個請求/流程裡「開一次檔案→用同一個 fs.FileHandle 做多個步驟→最後再關一次」。Effect.acquireRelease 把「開啟/關閉」成對綁好,外層 Effect.scoped 決定收尾時機;就算失敗或中斷也會用 LIFO 正確清理。
特別注意:資源的取得(acquire)階段是不可中斷(uninterruptible)的,以確保不會因為僅部分取得資源而讓系統處於不一致狀態。
import * as fs from "node:fs/promises"
import type { FileHandle } from "node:fs/promises"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
// 取得運行腳本的路徑
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
// 檔案路徑
const targetPath = path.join(scriptDir, "1-what-is-a-program.ts")
// 資源取得(Acquire):以唯讀方式開啟檔案,成功時產生 FileHandle
const acquireReadOnlyFileHandle = Effect.tryPromise({
try: () => fs.open(targetPath, "r"),
catch: () => new Error("Failed to open file")
}).pipe(Effect.tap(() => Console.log("File opened")))
// 資源釋放(Release):接收與 acquire 相同的 FileHandle 並關閉
const closeFile = (fileHandle: FileHandle) =>
Effect.promise(() => fileHandle.close()).pipe(Effect.tap(() => Console.log("File closed")))
// 資源使用(Use):在作用域內使用同一個 FileHandle
const useFile = (fileHandle: FileHandle) => Console.log(`Using File: ${fileHandle.fd}`)
// Acquire → Use → Release 皆由 acquireRelease 與 scoped 自動串接
const program = Effect.scoped(
Effect.acquireRelease(acquireReadOnlyFileHandle, closeFile).pipe(
Effect.flatMap(useFile)
)
)
Effect.runFork(program)
// 輸出:
// File opened
// Using File: 28
// File closed
Effect.acquireUseRelease:更簡潔的單一場景使用如果只是在單一區塊內用完(取得→使用→釋放),就用 Effect.acquireUseRelease 一次解決。程式碼最簡潔。我們可以將
const program = Effect.scoped(
Effect.acquireRelease(acquireReadOnlyFileHandle, closeFile).pipe(
Effect.flatMap(useFile)
)
)
修改成:
const program = Effect.acquireUseRelease(acquireReadOnlyFileHandle, useFile, closeFile)
acquireUseRelease的時機與好處Effect.scoped;同時強制提供 acquire/use/release,降低遺漏清理的風險。release 仍會執行,確保資源一定被釋放。Effect.acquireRelease + 外層 Effect.scoped 更合適。下方對照兩種寫法,關鍵在「資源取得(acquire)與使用(use)的生命週期是否落在同一個 Scope」:
Effect.scoped,Scope 關閉時 release 會按 LIFO 正確執行收尾程式。Effect.scoped 內取得資源、卻在外面繼續使用。型別不一定報錯,但語義已經錯誤,實際上FileHandle早已失效。// 取得運行腳本的路徑
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
// 檔案路徑
const targetPath = path.join(scriptDir, "1-what-is-a-program.ts")
// 與 day27 命名對齊
const acquireReadOnlyFileHandle = Effect.tryPromise({
try: () => fs.open(targetPath, "r"),
catch: () => new Error("Failed to open file")
}).pipe(Effect.tap(() => Console.log("File opened")))
const closeFile = (fileHandle: FileHandle) =>
Effect.promise(() => fileHandle.close()).pipe(Effect.tap(() => Console.log("File closed")))
const fileHandle = Effect.acquireRelease(acquireReadOnlyFileHandle, closeFile)
// 安全:在同一個 Scope 內取得並使用(對應「請求內」拿到連線就用完)
const programSafe = Effect.scoped(
Effect.gen(function*() {
const handle = yield* fileHandle // 此處仍在 Scope 內
yield* Console.log("Using file (safe)")
const buf = yield* Effect.tryPromise(() => handle.readFile())
yield* Console.log(buf.toString())
})
)
Effect.runFork(programSafe)
// 輸出:
// File opened
// Using file (safe)
// const hello = "Hello, World!"
// console.log(hello)
// File closed
// 風險:Scope 已關閉,但之後還拿著 handle 使用(像把連線把手外洩到背景任務)
const programRisky = Effect.gen(function*() {
const handle = yield* Effect.scoped(fileHandle) // 在這裡 Scope 已關閉
yield* Console.log("Using file after scope closed (risky)")
// 直接觀察 scope 外的把手狀態(純展示用,不代表可用)
yield* Console.log(handle)
yield* Effect.tryPromise({
try: () => handle.readFile(),
catch: () => "readFile failed"
}).pipe(
Effect.tapError((e) => Console.log(e))
)
})
// 建議:不要這樣寫(範例僅為展示風險)
Effect.runFork(programRisky)
// 輸出:
// File opened
// File closed
// Using file after scope closed (risky)
// FileHandle {
// _events: [Object: null prototype] {},
// _eventsCount: 0,
// _maxListeners: undefined,
// close: [Function: close],
// [Symbol(shapeMode)]: false,
// [Symbol(kCapture)]: false,
// [Symbol(kHandle)]: FileHandle {},
// [Symbol(kFd)]: -1,
// [Symbol(kRefs)]: 0,
// [Symbol(kClosePromise)]: undefined
// }
根據流程,我們會先「File opened」接著「File closed」,之後才在 Scope 外使用 FileHandle,但透過打印出來的結果可以看出 FileHandle 的 [Symbol(kFd)] 已是 -1,代表資源已被關閉。所以程式會報錯。這就是「過早關閉 Scope 卻仍在範圍外持有資源的引用」的典型例子。
本篇聚焦於以 Scope 作為資源生命週期的容器:清理動作以 LIFO 執行,透過 Effect.scoped 界定邊界,並用 Effect.addFinalizer 動態累積收尾。當資源具備取得與釋放兩階段時,以 Effect.acquireRelease(acquire, release) 將其成對綁定並交由外層 scoped 在成功、失敗或中斷時一律會自動正確運行收尾程式(其中 acquire 為不可中斷的程式以確保系統資源的一致性與完整性)。
若僅在單一區塊就會使用完資源,則以 Effect.acquireUseRelease(acquire, use, release) 一氣呵成,在程式上最為簡潔。必要時也可將 scoped 放在流程內部以縮小影響面、提早釋放暫時性資源。
最後我們還提到容易不小心過早關閉 Scope 卻仍在範圍外持有資源的引用。這種情況不會在 Editor 做出提醒,但實際上卻會造成程式出錯。
Yes!終於講完了,下一篇我們繼續講 Effect 中的並行執行!😀